Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | /** * API route for streaming per-problem vision recording video * * GET /api/curriculum/[playerId]/sessions/[sessionId]/problems/[problemNumber]/video * * Streams the MP4 video file for a specific problem with Range header support for seeking. * Supports lazy encoding: if frames exist but video doesn't, triggers encoding on-demand. */ export const dynamic = 'force-dynamic' import { createReadStream, statSync, existsSync, readdirSync } from 'fs' import path from 'path' import { NextResponse } from 'next/server' import { and, eq } from 'drizzle-orm' import { db } from '@/db' import { sessionPlans, visionProblemVideos } from '@/db/schema' import { withAuth } from '@/lib/auth/withAuth' import { getPlayerAccess, generateAuthorizationError } from '@/lib/classroom' import { getUserId } from '@/lib/viewer' import { VideoEncoder } from '@/lib/vision/recording/VideoEncoder' /** * Encode video lazily in the background. * Updates database status on success/failure. */ async function encodeLazily(videoId: string, framesDir: string, outputPath: string): Promise<void> { try { const result = await VideoEncoder.encode({ framesDir, outputPath, fps: 5, // Match VisionRecorder default quality: 23, preset: 'fast', }) if (result.success) { await db .update(visionProblemVideos) .set({ status: 'ready', fileSize: result.fileSize, }) .where(eq(visionProblemVideos.id, videoId)) console.log( `[problem-video] Lazy encoding complete: ${outputPath} (${(result.fileSize! / 1024).toFixed(1)} KB)` ) // Clean up frames after successful encoding await VideoEncoder.cleanupFrames(framesDir) } else { throw new Error(result.error || 'Encoding failed') } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' console.error(`[problem-video] Lazy encoding failed: ${errorMessage}`) await db .update(visionProblemVideos) .set({ status: 'failed', processingError: `Lazy encoding failed: ${errorMessage}`, }) .where(eq(visionProblemVideos.id, videoId)) } } /** * GET - Stream problem video with Range support * * Query params: * - epoch: Epoch number (0 = initial pass, 1-2 = retry epochs). Defaults to 0. * - attempt: Attempt number within the epoch (1-indexed). Defaults to 1. */ export const GET = withAuth(async (request, { params }) => { try { const { playerId, sessionId, problemNumber: problemNumberStr, } = (await params) as { playerId: string; sessionId: string; problemNumber: string } const { searchParams } = new URL(request.url) if (!playerId || !sessionId || !problemNumberStr) { return NextResponse.json( { error: 'Player ID, Session ID, and Problem Number required' }, { status: 400 } ) } const problemNumber = parseInt(problemNumberStr, 10) if (isNaN(problemNumber) || problemNumber < 1) { return NextResponse.json({ error: 'Invalid problem number' }, { status: 400 }) } // Parse epoch and attempt from query params const epochNumber = parseInt(searchParams.get('epoch') ?? '0', 10) const attemptNumber = parseInt(searchParams.get('attempt') ?? '1', 10) // Authorization check const userId = await getUserId() const access = await getPlayerAccess(userId, playerId) if (access.accessLevel === 'none') { const authError = generateAuthorizationError(access, 'view', { actionDescription: 'view recordings for this student', }) return NextResponse.json(authError, { status: 403 }) } // Verify session exists and belongs to player const session = await db.query.sessionPlans.findFirst({ where: and(eq(sessionPlans.id, sessionId), eq(sessionPlans.playerId, playerId)), }) if (!session) { return NextResponse.json({ error: 'Session not found' }, { status: 404 }) } // Get problem video with epoch/attempt filtering const video = await db.query.visionProblemVideos.findFirst({ where: and( eq(visionProblemVideos.sessionId, sessionId), eq(visionProblemVideos.problemNumber, problemNumber), eq(visionProblemVideos.epochNumber, epochNumber), eq(visionProblemVideos.attemptNumber, attemptNumber) ), }) if (!video) { return NextResponse.json({ error: 'Problem video not found' }, { status: 404 }) } // Handle different video statuses if (video.status === 'processing') { return NextResponse.json( { error: 'Video is being encoded', status: 'processing', retryAfterMs: 5000, }, { status: 202 } ) } if (video.status === 'recording') { return NextResponse.json({ error: 'Video is still being recorded' }, { status: 400 }) } if (video.status === 'no_video') { return NextResponse.json( { error: 'No video was recorded for this problem (camera may have been off)', }, { status: 404 } ) } // For 'ready' or 'failed' status, proceed to check if file exists // If file doesn't exist but frames do, lazy encoding will be triggered // Build video file path const videoPath = path.join( process.cwd(), 'data', 'uploads', 'vision-recordings', playerId, sessionId, video.filename ) // Check if video file exists if (!existsSync(videoPath)) { // Lazy encoding: check if frames exist and we can encode on-demand const baseName = video.filename.replace('.mp4', '') const framesDir = path.join( process.cwd(), 'data', 'uploads', 'vision-recordings', playerId, sessionId, `${baseName}.frames` ) if (existsSync(framesDir)) { // Check if frames directory has actual frame files try { const files = readdirSync(framesDir) const frameFiles = files.filter((f) => f.startsWith('frame_') && f.endsWith('.jpg')) if (frameFiles.length > 0) { // Check if ffmpeg is available const ffmpegAvailable = await VideoEncoder.isAvailable() if (ffmpegAvailable) { // Frames exist and ffmpeg available - trigger lazy encoding console.log( `[problem-video] Triggering lazy encoding for ${video.filename} (${frameFiles.length} frames)` ) // Update status to processing await db .update(visionProblemVideos) .set({ status: 'processing' }) .where(eq(visionProblemVideos.id, video.id)) // Trigger encoding asynchronously (don't await - let client poll) encodeLazily(video.id, framesDir, videoPath) return NextResponse.json( { error: 'Video is being encoded', status: 'processing', frameCount: frameFiles.length, retryAfterMs: 5000, }, { status: 202 } ) } else { console.error(`[problem-video] Frames exist but ffmpeg not available: ${framesDir}`) return NextResponse.json( { error: 'Video encoding not available on server' }, { status: 503 } ) } } } catch (err) { console.error(`[problem-video] Error checking frames directory: ${err}`) } } console.error(`[problem-video] Video file not found: ${videoPath}`) return NextResponse.json({ error: 'Video file not found' }, { status: 404 }) } // Get file stats const stat = statSync(videoPath) const fileSize = stat.size // Parse Range header for seeking const range = request.headers.get('range') if (range) { // Handle Range request (partial content) const parts = range.replace(/bytes=/, '').split('-') const start = parseInt(parts[0], 10) const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1 if (start >= fileSize || end >= fileSize) { return new NextResponse(null, { status: 416, headers: { 'Content-Range': `bytes */${fileSize}`, }, }) } const chunkSize = end - start + 1 const stream = createReadStream(videoPath, { start, end }) // Convert Node.js stream to Web stream const webStream = new ReadableStream({ start(controller) { stream.on('data', (chunk) => { controller.enqueue(chunk) }) stream.on('end', () => { controller.close() }) stream.on('error', (err) => { controller.error(err) }) }, }) return new NextResponse(webStream, { status: 206, headers: { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': String(chunkSize), 'Content-Type': 'video/mp4', }, }) } // Full file request (no Range header) const stream = createReadStream(videoPath) const webStream = new ReadableStream({ start(controller) { stream.on('data', (chunk) => { controller.enqueue(chunk) }) stream.on('end', () => { controller.close() }) stream.on('error', (err) => { controller.error(err) }) }, }) return new NextResponse(webStream, { status: 200, headers: { 'Accept-Ranges': 'bytes', 'Content-Length': String(fileSize), 'Content-Type': 'video/mp4', }, }) } catch (error) { console.error('Error streaming problem video:', error) return NextResponse.json({ error: 'Failed to stream video' }, { status: 500 }) } }) |